本科毕业设计 - 手机版泡泡堂

这是我的本科毕业设计,标题是《炸弹人游戏在手机上的实现》。接到课题后我立即就想到了《泡泡堂》,上网找了一下也没有看到它的手机版,决定就拿它移植了。主要用到的技术是J2ME MIDP2.0,使用Eclipse 3.2.2结合EclipseMe 1.6.6插件开发。

image.png-5.9kB

image.png-6.7kB

image.png-29.6kB

这是我的第一个手机游戏,也是第二个手机程序,边学边弄,而且还没完成时就开始在现在的公司工作了,又经常加班,做得很辛苦。虽然感觉收获挺大,但很可惜以后都不会再搞这方面的东西了。其中有很详细的注释,论文的排版也花了很大的心思,希望能给初学J2ME的人或者也要写毕业设计的一点帮助。由于时间和水平有限,游戏并没有真正意义上的完成,偶尔还会出点空指针错误,论文里的东西都是只是自己一相情愿的理解,可能会有些偏差,高手看了不要鄙视。

版权所有, 转载请注明出处

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
目   录
摘 要 I
Abstract I
第1章 引言 1
1.1 手机游戏简介 1
1.2 J2ME简介 1
1.2.1 概述 1
1.2.2 配置 2
1.2.3 简表 3
1.3 关于本课题 3
1.4 术语与缩写解释 4
第2章 开发平台与主要技术 5
2.1 开发平台 5
2.1.1 Eclipse 5
2.1.2 EclipseME 6
2.1.3 JDK 6
2.1.4 Wireless Toolkit 7
2.1.5 Proguard 8
2.1.6 诺基亚N6070 8
2.2 主要技术 8
第3章 需求分析 10
3.1 运行平台 10
3.2 界面要求 10
3.3 速度要求 10
3.4 具体需求 10
3.4.1 主要界面 10
3.4.2 闪屏 11
3.4.3 菜单 11
3.4.4 帮助与关于 11
3.4.5 角色 11
3.4.6 地图 11
3.4.7 泡泡 12
3.4.8 道具 12
3.4.9 游戏规则 12
3.4.10 关卡 12
3.4.11 其它 12
第4章 程序实现 13
4.1 整体框架 13
4.1.1 类设计 13
4.1.2 文件结构 15
4.2 启动 16
4.3 闪屏 18
4.4 菜单 20
4.4.1 模型层 20
4.4.2 视图层 22
4.5 文字换行与分页 24
4.6 地图设计 26
4.6.1 基本结构 26
4.6.2 砖块属性 28
4.6.3 地图绘制 31
4.7 资源准备 32
4.7.1 图形处理原则 32
4.7.2 本游戏的图形资源处理 33
4.7.3 声音资源准备 34
4.8 游戏基本原理 34
4.8.1 状态机 34
4.8.2 线程 35
4.8.3 FPS控制 35
4.9 场景类 37
4.10 图层 40
4.11 炸弹 41
4.11.1 创建与回收 41
4.11.2 更新状态 42
4.11.3 爆炸 44
4.11.4 引爆 45
4.11.5 清除爆炸效果 46
4.12 道具 47
4.13 角色 48
4.13.1 创建 48
4.13.2 更新 49
4.13.3 移动 52
4.13.4 设置炸弹 53
4.14 播放声音 54
4.15 资源装载与进度条 54
4.16 关卡 55
4.16.1 保存关卡配置 56
4.16.2 读取关卡配置 58
4.17 兼容性设计 59
4.18 打包与混淆 60
第5章 总结 63
参考文献(Reference) 64
致谢 65

炸弹人游戏在手机上的实现

专  业:软件工程 学  号:8000103337

学生姓名:蔡 剑 文 指导教师:万 立 中

摘 要
J2ME虚拟机在手机上的普及为手机游戏的发展提供了最适合的土壤,随后MIDP2.0的发布,特别是其中新增的GameAPI使得手机游戏开发者可以更专注于游戏性的增强而不再是繁琐的动画处理与地图设计,让开发过程变得更加方便迅捷,它是手机游戏发展的重要里程碑。

本文完整地描述了如何在Eclipse平台应用J2ME技术,特别是MIDP2.0中新增加的GameAPI在手机上实现一个炸弹人游戏(原型为网游《泡泡堂》),核心在于其游戏引擎的构建。其中涉及到的技术有Midlet框架、游戏状态机、线程、精灵、地图、关卡、程序优化及兼容性设计等。

第一章介绍了J2ME游戏背景,及对论文中术语的约定;

第二章介绍了本游戏的开发平台及要使用的主要技术;

第三章对游戏进行了简单的需求分析;

第四章是游戏的具体实现,并描述一些关键性技术;

第五章是对整个过程的总结,讲述心得与感想。

关键字:J2ME;MIDP2.0;GameAPI;Eclipse;手机游戏;炸弹人;泡泡堂

Implementation of J2ME-Based Game: Bomber
Abstract

Widespread use of KVM on the mobile phone increases the speed of mobile game development. With the release of MIDP 2.0, especially the newly added GameAPI, game developer can concentrate on the enhance of game performance, freeing themselves of animation making and game map design. Game development becomes rapid and more and more convenient. MIDP 2.0 is the milestone of mobile game development.

The thesis introduces the technology of J2ME based on the integrated development environment of Eclipse. It is mainly about the implementation of a game—bomber (The Chinese online game, PopTang, is its prototype). The core of the thesis focuses on the game engine construction. The technology concerned includes the MIDlet class, thread, sprite, game map, levels, the optimization of the game and its compatibility, etc.

The First chapter is the introduction of game background and the terms used in the thesis; The second chapter is about the integrated development environment and the mainly used technology; The third chapter illustrate the demand analysis briefly; The fourth chapter is the concrete implementation of the game and the decription of some key technology used in the game. And the fifth chapter is the summary and what I have learned from the game development.

Keywords: J2ME;MIDP2.0;GameAPI;Eclipse;Mobile Games;Bomber;Poptang

第1章 引言

1.1 手机游戏简介

1998年诺基亚公司年发布了全球第一款内置游戏的手机——“变色龙6110”,内置贪食蛇、记忆力、逻辑猜图三款游戏,受到了全世界的欢迎,其他厂商纷纷跟进。

那时候的手机游戏都是在手机出厂时固化的,而且要针对不同机型量身订做,因此只是手机厂商吸引顾客一种手段。受当时硬件平台的限制,手机游戏大多简单朴素,可玩性不高。

J2ME的普及为手机游戏的发展提供了最适合的土壤。因为JVM的平台无关性使得标准的J2ME游戏几乎可以在所有内置了JVM的手机上运行,一方面提高了游戏的传播性,另一方面提高了开发者的积极性。于是逐渐开始有第三方公司专门从事J2ME游戏的开发,形成了手机游戏产业化。随着硬件水平的发展,手机游戏也一改往日的朴素形象,开始加入了更多的媒体元素,由最早的黑白两色、单音发展到了彩色动画与和弦音效甚至3D加立体声,手机厂商甚至推出了专门的游戏手机,如诺基亚的N-Gage。

然而更多的情况下,手机只是PC与游戏机的一种替代。条件满足的话,人们肯定更愿意选择速度与交互性都更加出色的后者。与之相比,手机游戏最大的优势便是其易携性与网络支持性。因为它的易携,使得它可以满足人们随时随地玩游戏的需求,是无聊时打发时间的最好选择。至于网络支持性,在目前网络带宽有限的情况下优势还不明显,预计在将来成为手机网游发展的最大助力。

基于以上差别,目前最有可能成功的手机游戏就是那些临时小游戏,它可为广泛的玩家在所有的社交场合提供娱乐。

1.2 J2ME简介

1.2.1 概述

Java技术是一系列产品的集合,目前主要包括Java2平台标准版(Java 2 platform Standard Edition, J2SE)、Java2平台企业版(Java 2 platform Enterprise Edition, J2EE)、Java2平台微型版(Java 2 platform Micro Edition, J2ME)和Java卡平台。Java技术的体系结构如图 1.2 1所示。

image.png-119.4kB

图 1.2 1 Java技术的体系结构

J2ME为运行在嵌入式消费类电子产品的设备,如移动电话、PDA、游戏终端之上的应用程序提供了一个健壮的、灵活的环境。与J2SE、J2EE和Java Card一样,J2ME同样包含一个小型的虚拟机和一系列的Java API。J2ME还提供了灵活的图形用户界面、健壮的安全模型、广泛的联网协议支持。目前J2ME平台已经部署到上亿个的设备上,前景非常看好。

J2ME平台由多种配置(Configuration)、简表(Profile)和可选包(Optional Package)组成。平台的实现者和应用程序的开发者可以从中选择并组合出一个完整的Java运行环境来满足特定范围内的设备需求。每种组合都应该使这一系列设备的内存、处理器和I/O能力达到最优化。J2ME专家组之所以采取这种灵活的设计结构主要是为了满足市场上不同种类的嵌入式设备的需求,这些设备在软件和硬件特性上都存在巨大的差异,一种规范很难将它们统一起来。

1.2.2 配置

目前,J2ME平台主要包括两个配置:CLDC和CDC。

  • CLDC是两个配置中较小的一个,为具有间断性联网能力、较慢的处理器和有限内存的设备设计的。这些设备包括移动电话、双工呼叫器和入门级的PDA,它们通常具有16位或32位的CPU、128KB~512KB可用于Java平台实现和相关应用程序的内存。

  • CDC是为处理器能力较强、内存空间更大、联网能力更出色的设备设计的。这些设备包括电视机顶盒、车载娱乐系统、高端PDA等。CDC包含一个具有完备特性的Java虚拟机,比CLDC更大的J2SE平台的子集。CDC的目标设备通常具有32位或者64位的处理器,2MB以上的可用于Java平台实现和相关应用程序的内存。

1.2.3 简表

CLDC1.0规范推出之时并没有引起业界的广泛关注,因为你很难基于CLDC开发出有用的应用程序。Sun随后发布了MIDP1.0规范,整个移动开发社区为之震动。因为MIDP为开发者提供了应用程序模型、用户界面、持久性数据存储等高层的API,这使得为移动终端设备开发可视化应用程序成为可能。

移动信息设备简表(MIDP)是为移动电话和入门级PDA设计的。它为移动应用程序提供了所需的全部核心功能,包括应用程序模型、用户界面、持久性数据存储、联网能力(在CLDC中定义,在MIDP中实现)及应用程序管理。目前应用非常广泛的MIDlet就是在MIDP中定义的。CLDC与MIDP组成的完备Java运行环境提升了手持设备的能力,并且最小化了设备内存和电源的消耗。

1.3 关于本课题

《泡泡堂》游戏由韩国NEXON公司开发,在炸弹人游戏的基础上增加了网络对战功能与丰富的道具元素,应用了多彩的游戏动画和可爱的人物形象,一上市就获得了相当广泛的欢迎。

image.png-186.5kB

图 1.3 1 NEXON公司的《泡泡堂》

本课题将在手机上实现一个单机版的泡泡堂游戏,继承原泡泡堂可爱的人物形象与道具元素,实现玩家与电脑之间的对战。核心目的在于使用J2ME技术构建一个完整的游戏引擎。

1.4 术语与缩写解释

  • KVM: 全称Kilobyte Virtual Machine,J2ME虚拟机;
  • IDE: 全称Integrated Development Environment,集成开发环境;
  • WTK: 全称Wireless Toolkit,是Sun公司发布的J2ME应用开发套件;
  • MIDlet: 一个MIDP应用程序称作MIDlet——MIDP小应用程序,这个概念与J2SE中的applet十分类似;
  • JAR: 全称Java Archive File,是Java的一种文档格式。本文中用以代指J2ME程序经编译后的执行文件,可以传至手机中直接运行;
  • NPC: 全称Non Player Character,即“非玩家控制角色”;
  • FPS: 全称Frames Per Second,即每秒刷新帧数;
  • AI: 全称Artificial Intelligence,即人工智能;

第2章 开发平台与主要技术

2.1 开发平台

本游戏开发平台为Eclipse3.2.2 + EclipseME1.6.6 + JDK1.5.0.9 + Wireless Toolkit 2.5 + Proguard3.6,模拟测试平台为N6230、N6255、WTK模拟器与semc_java_me_cldc_sdk_2_2_4(索爱全系列模拟器),真机测试平台为诺基亚N6070、诺基亚N6080、索爱K750C、摩托罗拉V1。图形处理工具为Fireworks 8.0、ACDSee 3.1、PNG Mate 2.0。

2.1.1 Eclipse

2001 年11 月 IBM 宣布捐出了价值 4 千万美金的开发软件给开放源码的 Eclipse 项目。

Eclipse 是替代IBM Visual Age for Java(以下简称IVJ)的下一代IDE 开发环境,但它未来的目标不仅仅是成为专门开发Java 程序的IDE 环境,根据Eclipse 的体系结构,通过开发插件,它能扩展到任何语言的开发,甚至能成为图片绘制的工具。更难能可贵的是,Eclipse 是一个开放源代码的项目,任何人都可以下载Eclipse的源代码,并且在此基础上开发自己的功能插件。也就是说未来只要有人需要,就会有建立在Eclipse 之上的COBOL,Perl,Python 等语言的开发插件出现。同时可以通过开发新的插件扩展现有插件的功能,比如在现有的Java 开发环境中加入Tomcat 服务器插件。可以无限扩展,而且有着统一的外观,操作和系统资源管理,这也正是Eclipse 的潜力所在。在本项目中,Eclipse正是通过第三方插件实现了开发J2ME应用。

image.png-162.4kB

图 2.1 1 Eclipse启动画面

image.png-103.5kB

图 2.1 2 Eclipse工作区界面

网站:http://www.eclipse.org

2.1.2 EclipseME

EclipseME是Eclipse的一个插件,通过安装它以使得我们可以在Eclipse下开发J2ME应用。我们可以像建立普通Java Project一样建立一个J2ME Midlet Suite,并调用WTK下的模拟器来运行或者调试它。
网站:http://eclipseme.org

2.1.3 JDK

即Java Development Kit,是开发Java应用的基础,Eclipse的运行也要依赖于它。

image.png-28kB

图 2.1 3 JDK1.5.0.9

2.1.4 Wireless Toolkit

Wireless Toolkit(以下简称WTK)是Sun提供的J2ME开发工具,用以编译J2ME程序,并且内置了模拟器和分析工具用于运行调试。因为它只提供一些基本的功能,并不是一个高效的IDE,所有人们一般在其它IDE下编写代码,再调用WTK来编译与运行。

image.png-104.3kB

图 2.1 4 WTK2.5

2.1.5 Proguard

Proguard是一个出色的混淆器,用以增加程序被破译的难度,并可以减小最后生成jar的体积,后文中将会详细的应用介绍。

2.1.6 诺基亚N6070

操作系统:Nokia S40 2nd
网络频率:GSM/GPRS/EDGE;900/1800/1900MHz
屏幕参数: 65536色CSTN彩色屏幕;128×160像素;
Java扩展:CLDC1.1;MIDP 2.0
内存堆栈:1M Byte

image.png-36.2kB

图 2.1 5 N6070

2.2 主要技术

本游戏实现大量应用了MIDP2.0中的用于游戏开发的类库,即GAME API。这组简洁的API放在javax.microedition.lcdui.game包中。

下面介绍一下各个类的用途。

  • GameCanvas类

    GameCanvas是Canvas的子类,它代表了游戏的基本界面。在GameCanvas上进行绘图代替了直接在Canvas绘图。GameCanvas的主要改进在于它自动实现了双缓冲,并提供了轮询键盘输入事件的方法。使用GameCanvas可统一游戏的基本框架。

  • Layer类

    Layer类是一个抽象类,代表了界面的一个基本显示单元。每一个Layer都有位置、大小、可见性等属性,以及更改这些属性的方法。我们并不直接使用Layer类,而是使用它的两个子类——Sprite类和TiledLayer类。

  • LayerManager类

    LayerManager类负责管理Layer,并通过按照一定的顺序在画布上进行来实现分层次的自动渲染。LayerManager还提供了一个可视窗口的概念(View window),因为游戏地图往往比界面画面大很多,通过设定当前可视窗口的大小和位置,可以轻松地实现滚屏等常见效果。

  • Sprite类

    Sprite类是Layer的一个子类,它面上的意思是“精灵”。这是一个游戏开发的专有名词,代表了界面上一个基本的可视单元。这个词早在红白机 之前的游戏开发中就被使用了,并沿用至今。例如,在一个追逐游戏中可能有两个Sprite对象:猫和老鼠。一个Sprite对象一表会包含好几帧画面,按照一定的顺序和频率显示这些帧来实现动画的效果。Sprite类还提供了画面的翻转、旋转及简单的碰撞检测等。

  • TiledLayer类

    TiledLayer类是Layer的另一个子类。Tile也是二维游戏开发的一个经典词汇。你可能听说过基于砖块的游戏这一提法,它的思想是把界面的背景画面划分成一个二维的表格,并使用不同的图像砖块去填充它。这样,只要用几个不同的图像砖块就可以组合出很大的图片,而所占的资源空间仅仅为原先的几十分之一甚至更小。实现一个健壮的Tile类需要花费一些成本,幸运的是TiledLayer就是基于Tile的地图的一个很好的实现。使用它可以方便地构建起美观的游戏地图。

第3章 需求分析

3.1 运行平台

本游戏目标运行平台为所有支持CLDC1.1、MIDP2.0,屏幕分辨率不小于128×128的手机设备,并要求在各型号手机上均有良好的兼容性。

3.2 界面要求

参照原版《泡泡堂》游戏,要求精致、美观,人物形象可爱。所有界面均须使用低级UI绘制。

3.3 速度要求

要求在满足平台需求的各手机上运行时画面流畅,声音播放正常,操作无明显延迟感。

3.4 具体需求

3.4.1 主要界面

要求游戏具有闪屏(Splash)、菜单、游戏主界面、帮助、关于等界面,各界面切换关系如图 3.4 1所示:

image.png-17.2kB

图 3.4 1 游戏界面间切换

3.4.2 闪屏

要求显示游戏菜单前,必须先自动、连续显示两个闪屏,第一个闪屏用于显示南昌大学徽标、南昌大学校名文字、及毕业设计字样等信息;第二个闪屏用于显示所设计的程序的图标、程序名称及版本等。

两个闪屏的显示由时间控制,自动消失,也可以接受用户的按键事件,用户在任何一个闪屏按任意键,当前闪屏自动消失。

闪屏图案排列要考虑多种方案,如果对于128x128左右的小屏幕,则不显示南昌大学徽标,如果大屏幕,则显示全部图案,且游戏菜单无论在大、小屏幕都要垂直、水平居中。

3.4.3 菜单

游戏者可通过菜单界面可选择开始游戏、帮助、关于、退出游戏。

3.4.4 帮助与关于

游戏者可通过菜单进入帮助或关于界面查看游戏规则与基本操作及关于信息,该界面必须实现中英文的换行显示及上下键滚动屏幕或翻页功能。

3.4.5 角色

  • a) 游戏有玩家与敌人(后文中均NPC指代)两种游戏角色,玩家只能有一个,由游戏者控制,敌人可能有多个,由游戏人工智能控制。
  • b) 角色出生时具有基本的移动速度、泡泡数目属性,这些属性可在获得相应道具后得到提升。
  • c) 角色可以在地图允许的单元格内朝上下左右进行移动、放置炸弹、拾获道具。
  • d) 如果玩家移动方向上有可移动建筑,且该建筑同方向上下一位置无建筑或建筑具有可通行属性,则移动该建筑到下一位置,实现“推动”效果。
  • e) 所有角色唯一的死亡方式为被自己或对方的泡泡炸死。
  • f) 玩家被炸死后可在原出生地复活,敌人生命仅有一次。
  • g) 角色有多种动画状态,如下:
    • 出生状态,显示角色在原地旋转动画;
    • 移动状态,显示角色上下左右移动动画;
    • 休息状态,一段时间无指令后,角色进入休息状态,显示眨眼睛动画;
    • 死亡进行状态,角色死亡后显示一段跳起落地的动画,并伴随闪烁;
    • 已死亡状态,角色消失。

3.4.6 地图

游戏地图为N×N的网格,包括地表贴图与地面建筑。地面建筑包括三种属性,分别为可破坏、可移动、可通行,每一个地面建筑物可能拥有其中一种或多种属性。可破坏的建筑被破坏后将消失,并有一定概率在原位置产生一个随机道具。

3.4.7 泡泡

即炸弹,角色可以在地图允许的单元格内放置泡泡,在一定延时后爆炸,爆炸后以炸弹所在位置为中心向上下左右产生冲击波。冲击波有如下特性:

  • 可穿透角色,导致角色死亡;
  • 可穿透道具,使道具销毁;
  • 可穿透具可通行属性的建筑;
  • 遇不可破坏建筑或地图边界后中止传播;
  • 遇可破坏建筑后中止传播,并摧毁建筑;
  • 可穿透其它泡泡,并立即引发其爆炸。

3.4.8 道具

建筑物被摧毁后将有一定概率在原地随机产生道具,角色经过道具所在地图单元格后将自动拾获道具并获得相应奖励,目前四种道具有以下四种:

  • 鞋子,增加角色移动速度;
  • 泡泡,增加角色携带泡泡数量;
  • 能量药水,增强角色施放泡泡爆炸后的冲击波强度(范围);
  • 金币,增加角色关卡分值。

3.4.9 游戏规则

玩家与NPC在预置地图中自由活动,所有NPC被消灭后进入下一关卡,玩家被消灭后如果有剩余生命则可获得重生,否则游戏结束。

3.4.10 关卡

游戏应该至少有两个关卡,要求关卡地图、背景音乐、NPC数量、角色与NPC出生位置可灵活配置。

3.4.11 其它

  • 游戏应该具有暂停功能;
  • 游戏过程中可随时呼出菜单进行操作;
  • 为保证游戏能完全支持NOKIA S40 2nd平台,游戏体积必须在128KB以下。

##第4章 程序实现##

4.1 整体框架

4.1.1 类设计

游戏开发过程完全基于面向对象的思想,使各个模块功能尽可能独立。游戏共设计了11个类:

  • com.cpiz.poptang.PopTang

    PopTang继承自javax.microedition.midlet.MIDlet,程序从它的构造函数PopTang()被调用而开始,通过它的destroyApp(boolean arg0)方法而结束。PopTang是整个游戏的核心,所有的可视对象都必须调用它的setDisplayable(Displayable displayable)方法使自身出现在屏幕上。如果把游戏运行的过程比喻为拍摄一部电影的话,那PopTang类可以说是本片的导演兼摄影师。

    PopTang中还定义了很多游戏常量,它们被声明为public static final,使得在本游戏的任何一个类中都可以通过如int a = PopTang. SPLASH_TIME(获得Splash显示时间)的方式而获得常量值。这种方式使得对游戏的配置变得相当灵活,而在真正编译的时候这些常量是会被混淆器内联,并不会影响效率。

  • com.cpiz.poptang.NcuscSplash

    NcuscSplash继承自javax.microedition.lcdui.Canvas,它是游戏的第一个闪屏,也是游戏显示的第一个可视对象,用来显示本毕业设计的相关信息,具体实现将在4.3节详述。

  • com.cpiz.poptang.Splash

    Splash同样继承自javax.microedition.lcdui.Canvas,是游戏的第二个闪屏。它继NcuscSplash后出现,用来显示游戏的启动画面与版本号。

    Splash的内部实现几乎与NcuscSplash一样,但考虑到一个真正的游戏只需要一个闪屏,并没有与毕设信息闪屏兼容实现,而是分别写了两个闪屏类,到时候只需删去NcuscSplash类,再将PopTang稍微修改就可以满足只显示一个闪屏的需求。

  • com.cpiz.poptang.Menu

    Menu继承自javax.microedition.lcdui.Canvas,是游戏的目录,在构造Menu的时候要将PopTang对象作为参数传入,以使得可以从Menu调用PopTang提供的方法进入不同的功能界面。

  • com.cpiz.poptang.Help

    Help继承自javax.microedition.lcdui.Canvas,是游戏的帮助和关于界面。通过构造时传入的整型参数type来区分显示帮助或者关于,type是Help类中定义的常量:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    /**
    * 构造参数,表示构造“帮助”界面
    */
    public static final int HELP = 0;

    /**
    * 构造参数,表示构造“关于”界面
    */
    public static final int ABOUT = 1;
  • com.cpiz.poptang.StringLayout

    StringLayout是一个自定义的文字布局类,以提供中英文文本在不同分辨率和字体的手机平台上能正确换行显示并滚动翻页。

  • com.cpiz.poptang.Game

    Game继承自javax.microedition.lcdui.game.GameCanvas并实现了Runnable接口,它是游戏的“舞台”。

    Game中还定义了一个内部类Loading,它也实现了Runnable接口,创建了一个新的线程用于资源的装载,Game类与之配合来绘制装载进度条。

  • com.cpiz.poptang.Stage

    Stage类是用来描述游戏关卡的专用数据结构,该类中定义了地图布局,玩家出生点,NPC数量及NPC出生点。

    Stage类有两个方法,loadStage(int stageIndex)用于从文件装载关卡信息,saveStage(int stageIndex)用于将关卡信息写入文件。其中saveStage方法只有在地图设计器(将在0节中介绍)中才会使用到,考虑到游戏程序体积,手机中使用的Stage仅保留了loadStage方法。

  • com.cpiz.poptang.Map

    Map类是对地图的抽象,构造Map时通过对Stage参数的解析获得具体的游戏地图。游戏地图与许多对象存在着交互,是本项目中最复杂的部分之一,将在4.6节详细介绍。

  • com.cpiz.poptang.Character

    Character继承自javax.microedition.lcdui.game.Sprite,玩家与NPC都是该类的实例。

  • com.cpiz.poptang.Bomb

    Bomb类是对泡泡的抽象,但它并不是一个精灵,因为使用精灵难以实现泡泡的爆炸效果,Bomb的爆炸是在Map中修改贴图实现的。

整个项目各类间关系如图4.1-1所示

image.png-117.6kB

图 4.1 1 类关系图

4.1.2 文件结构

本游戏的文件层次如图 4.1 2所示:

image.png-2.8kB

图 4.1 2 文件层次图

  • com.cpiz.poptang为源程序所属的package,所有的11个类都在这一包下。
  • img下是图片资源,均为png格式。其中关卡图片以数字编号,以方便关卡动态加载。
  • mid下是背景音乐,同关卡图片一样,以数字命名。
  • stage下是关卡文件,这是一个二进制文件,由Stage类的loadStage方法来解析。

在项目生成最后的JAR时,将自动添加META-INF目录,存放MANIFEST.MF文件用以描述该程序。经过了混淆后除MIDlet的子类以外的所有类都将被更名为a.class,b.class,c.class……,并移至项目根目录下。

4.2 启动

游戏的最终形式是一个.jar格式的文件,这实际是一个以zip格式的压缩文件,它与zip的唯一区别在于其中有一个META-INF的目录,其中有一个MANIFEST.MF文件用以描述该项目,符合格式标准的内容可以被所自动识别,在程序运行中我们也可以使用Midlet.getAppProperty(“xxxx”)的方法来获得该文件中的属性值。

本游戏的MANIFEST.MF文件内容如下:

1
2
3
4
5
6
7
Manifest-Version: 1.0
MicroEdition-Configuration: CLDC-1.1
MIDlet-Name: 泡泡堂
MIDlet-Vendor: cpiz.com
MIDlet-1: PopTang,/img/icon.png,com.cpiz.poptang.PopTang
MIDlet-Version: 0.0.88
MicroEdition-Profile: MIDP-2.0

第一行Manifest-Version: 1.0描述了该文件的格式版本,一般为1.0

第二行MicroEdition-Configuration: CLDC-1.1定义了配置为CLDC-1.1

第三行定义了程序的名称,它将显示在手机的程序列表中。

第四行是程序的提供商。

第五行描述了程序的入口,因为一个程序可能有多个MIDlet应用,所以加入了一个数字标识。该行使用了逗号分为了三段。第一段“PopTang”表示了MIDlet应用的名称,第二段“/img/icon.png”表示了该MIDlet应用所显示的图标,第三段“com.cpiz.poptang.PopTang”表示了该MIDlet应用的完整类名。

第六行是软件的版本号。

第七行是定义简表为MIDP2.0,它与第二行共同约束了运行平台,不符合这两条要求的设备将不能运行该程序。

下面两副图是MANIFEST.MF配置的效果:
image.png-5.9kB image.png-6.4kB
在程序列表中可以看到图标效果与“泡泡堂”名称,查看程序详情可以看到版本、供应商等信息,这些都是MANIFEST.MF文件中的内容。

运行程序后,系统通过MANIFEST.MF找到要加载的MIDlet类,即com.cpiz.poptang.PopTang,对其实例化。在PopTang的构造方法中,依次创建了NcuscSplash、Splash、Menu、Game这四个类的实例。虽然这样会使程序打开的时间稍长,但可以充分加载资源,避免了可能发生的空指针异常。

1
2
3
4
5
6
7
8
9
10
11
12
/**
* PopTang构造函数
*/
public PopTang()
{
ncuscSplash = new NcuscSplash(this);
splash = new Splash(this);
menu = new Menu(this);
game = new Game(this);

display = Display.getDisplay(this);
}

PopTang实例构造后会自动调用其startApp()方法,在这个方法中我们调用了NcuscSplash的showMe(),让NcuscSplash对象显示在屏幕上。至此,游戏的启动完成。

1
2
3
4
5
6
7
/**
* 启动游戏,显示splash
*/
protected void startApp() throws MIDletStateChangeException
{
ncuscSplash.showMe();
}

4.3 闪屏

本游戏制作了两个闪屏(Splash),分别用来显示毕设信息和游戏信息。毕设信息闪屏要求居中显示校名、校徽、学院、指导老师与学生等内容,并能自动适应屏幕分辨率;游戏信息闪屏则显示游戏Logo及游戏版本。

闪屏类都继承自Canvas,并设定为全屏模式,在paint(Graphics g)方法中定义内容。为实现对分辨率的自适应,我们先使用了Canvas.getHeight()方法获得屏幕的高度,根据它来判断是否要绘制校徽,并计算出显示内容的总高度,再通过这两个高度值计算出让内容垂直居中的画笔起点水平坐标。

让内容居中显示则简单得多,只须计算出屏幕的水平中心坐标,并在绘制的时候将对齐参数设为Graphics.HCENTER | Graphics.TOP即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Font font = Font.getFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_SMALL);

// 检测屏幕高度以设置垂直居中
if (getHeight() > 128)
{
imgNcuName = Image.createImage(PopTang.IMAGE_SRC_NCUNAME);
imgNcuLogo = Image.createImage(PopTang.IMAGE_SRC_NCULOGO);

splashHeight = imgNcuName.getHeight() + imgNcuLogo.getHeight()
+ PopTang.SPLASH_ROW_SPACING * 4 + font.getHeight() * 4;
}// 屏高大于128则绘制logo
else
{
imgNcuName = Image.createImage(PopTang.IMAGE_SRC_NCUNAME);

splashHeight = imgNcuName.getHeight()
+ PopTang.SPLASH_ROW_SPACING * 4 + font.getHeight() * 4;
}// 屏高小于128则仅绘制"南昌大学"文字

// 计算y轴起始绘制位置
x = getWidth() / 2;
y = (getHeight() - splashHeight) / 2;

接下来便可从上而下依次绘制,并将y坐标值递增,具体可参考附录源代码。

闪屏出现后按任意键可以切换到下一屏幕,也可以在一定时间后自动切换,本游戏设定的闪屏显示时间为3秒,是在PopTang类中定义的全局常量。

1
2
3
4
/**
* splash显示时间,默认为3000毫秒
*/
public static final int SPLASH_TIME = 3000;

延时切换是通过Timer与TimerTask类实现的,在showMe()方法被调用的时候此计时器开始生效,并加入一个计划任务,让它在3秒后调用showNextScreen()方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 在屏幕上显示自身
*/
protected void showMe()
{
// 使用MIDP2.0自带的全屏模式
this.setFullScreenMode(true);

// 初始化计时器,一定时间后跳转至游戏菜单
timer = new Timer();
timer.schedule(new TimerTask() {
public void run() {
showNextScreen();
}
}, PopTang.SPLASH_TIME
);

midlet.setDisplayable(this);
}

showNextScreen()方法中,我们取消计时器任务,并调用下一个splashshowMe()方法。

1
2
3
4
5
6
7
8
9
/**
* 显示下一屏
*/
private void showNextScreen()
{
timer.cancel();
timer = null;
midlet.splash.showMe();
}

要实现按任意键切换到下一屏幕只须要在keyPressed(int keyCode)方法中调用showNextScreen()即可。

游戏信息闪屏的实现基本一样,只是对paint(Graphics g)方法内容进行了修改,showNextScreen()方法也改为了显示游戏目录。

闪屏在128×128与128×160分辨率效果如下:
image.png-4.4kB image.png-6.7kB

image.png-16.1kB image.png-11.6kB

4.4 菜单

Splash类的showNextScreen()方法中调用了Menu.showMe()来显示游戏菜单。考虑到以后将不用再使用这两个Splash对象,该方法对它们进行了回收,为游戏腾出更多的可用内存。

1
2
3
4
5
6
7
8
9
10
/**
* 显示菜单
*/
protected void showMe()
{
midlet.setDisplayable(this);
midlet.ncuscSplash = null;
midlet.splash = null;
System.gc();
}

为了便于理解,这里将菜单的实现分为两个层——模型层与视图层。模型层是菜单数据的抽象,视图层则只负责将模型层还原显示在屏幕上。

4.4.1 模型层

模型层的菜单选项是一个字符串数组,在PopTang.MENU_OPTIONS定义。

1
2
3
4
5
6
7
8
9
/**
* 菜单选项
*/
public static final String[] MENU_OPTIONS = {
"START",
"HELP",
"ABOUT",
"QUIT"
};

与之配合的是一个int selectedIndex作为数组索引来标识被选中项,它被初始化为0,即默认选择START菜单项。接下来只须在keyPressed(int key)响应相应的按键来修改selectedIndex值就实现了菜单的选择。

当检测到键或数字键2表示上一项,检测到键或数字键8表示下一项。检测到开火键或数字5表示打开当前项功能。这里要注意菜单的滚动,当前项为第一项的时候再按↑键应该转到最后一项,而当前项为最后一项时按键应该转回第一项来。

keyPressed(int key)方法代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
protected void keyPressed(int key)
{
// 按键甄别依据Nokia 6070键位码
switch (getGameAction(key))
{
case Canvas.UP:// 向上
case Canvas.KEY_NUM2:// 2键
selectedIndex = (selectedIndex + PopTang.MENU_OPTIONS.length - 1)
% PopTang.MENU_OPTIONS.length;
repaint();
break;
case Canvas.DOWN: // 向下
case Canvas.KEY_NUM8: // 8键
selectedIndex = ++selectedIndex % PopTang.MENU_OPTIONS.length;
repaint();
break;
case Canvas.FIRE: // 中键
case Canvas.KEY_NUM5: // 5键
goMenu();
break;
default:
break;
}
}

goMenu()方法为检测selectedIndex的值,并进入相应的功能。

4.4.2 视图层

视图层负责菜单背景、选项与光标的绘制,其中前两项是静态的,而光标则要随当前选中项移动且附加了一个不断跳动的动画效果。

在讲述视图层实现之前,先要介绍一下贯穿整个游戏图形绘制的相对坐标概念。
游戏要在各种分辨率的手机上运行,而基于绝对坐标绘制会使游戏在分辨率大于128×128的设备上布局混乱,严重影响游戏体验。为使游戏在各机型上画面居中,本项目中使用了相对坐标系统。

首先要找到画面绘制起点,我们将之命名为锚点,所有的图形元素都相对于锚点绘制。该点x坐标为设备屏宽减去游戏画面宽度后的二分之一,y坐标为设备屏高减去游戏画面高度后的二分之一。在128×128分辨下,锚点坐标为(0, 0)。锚点计算的代码实现如下:

1
2
3
4
5
6
7
8
9
// 画面绘制锚点
private int anchorX = 0;
private int anchorY = 0;

screenWidth = getWidth();
screenHeight = getHeight();

anchorX = (screenWidth - PopTang.GAME_WIDTH) / 2;
anchorY = (screenHeight - PopTang.GAME_HEIGHT) / 2;

绘制图形时画笔起点分别加上锚点的xy坐标值,这样所有的图形都会落在屏幕中央128×128的区域内,实现了居中效果。

Menu类的paint(Graphics g)方法中,首先绘制了菜单的背景图。由于背景图片大小正好为128×128,则背景图绘制起点正好为锚点。

1
2
// 绘制菜单背景
g.drawImage(imgMenuBg, anchorX, anchorY, Graphics.LEFT | Graphics.TOP);

接下来绘制菜单项。菜单内容源自PopTang.MENU_OPTIONS数组,在绘制的时候对其进行遍历,每绘制一个元素后让画笔y坐标加上字体行高与一定的行距,则为下一行起点。为了让菜单项内容水平居中,在绘制时使用了对齐参数Graphics.HCENTER。

光标绘制是在菜单项绘制的同时完成的,程序对菜单项进行遍历时会检查当前selectedIndex值,当selectedIndex等于数组当前索引时则在文字前绘制光标。这里要重点谈到光标的闪烁(跳动),一个粉红色的泡泡仿佛有生命一般,在不停地跳动,这是本游戏中用到的第一个动画效果,也是唯一一个没有用到MIDP2.0技术的动画效果。

image.png-10kB

图 4.4 1 光标闪烁效果

光标资源是一个28×15的png图片,使用的时候把它当14×15的两帧,分别显示泡泡的两种姿态,光标绘制只须将这两帧间按一定频率连续切换即可。

image.png-0.8kB

图 4.4 2 光标源图

Graphics.drawImage方法没有提供绘制部分Image的功能,但可以通过Graphics.setClipGraphics.drawImage结合来实现。setClip方法就是在图片上设置一块区域,该区域被划分后使用drawImage方法时只有落在clip区域里的图形才会被绘制出来,这非常类似喷漆时使用的遮喷工艺。
image.png-4.9kB image.png-4.9kB

如图4.4 3与图4.4 4所示,对光标显示区(白色区)设置clip之后,只有该区域内的泡泡才是可见的。这时只要更改泡泡源图的x坐标就实现了帧的切换,不间断地切换并绘制则形成了动画效果。在绘制完后要记得让将整个屏幕设置为clip区,否则其它绘图将会无效。

本游戏中光标的帧换是根据一个boolean wink信号量来判断的。Menu里有一个Timer使得每经过500ms让画面重绘,并使wink = ! wink,在绘制时根据wink真假使光标图产生相应的偏移便实现了不停的闪烁。

1
2
3
4
5
6
7
8
9
10
11
12
// 绘制光标
if (i == selectedIndex)
{
// 计算偏移以显示光标动画效果
int offset = wink ? 0 : PopTang.CELL_WIDTH;

g.setClip(x - PopTang.MENU_CURSOR_SPACING, y - PopTang.CELL_HEIGHT,
PopTang.CELL_WIDTH, PopTang.CELL_HEIGHT);
g.drawImage(imgCursor, x - PopTang.MENU_CURSOR_SPACING - offset,
y, Graphics.LEFT | Graphics.BOTTOM);
g.setClip(0, 0, screenWidth, screenHeight);
}

MIDP1.0时代,使用Graphics.setClip是切换帧的主要方法,而在MIDP2.0中,使用SpriteTiledLayer可以更方便地实现。本处之所以使用setClip方法,仅是对MIDP1.0的一种演练。

4.5 文字换行与分页

游戏的帮助与关于是一个纯文字显示界面,由于只允许使用低级UI,使得文字显示只能通过Graphics.drawChar或Graphics.drawString方法将其绘制在画布上。而市场上的手机有千万种,各机型分辨率、文字大小都有自己的配置,甚至在同一台手机上,拉丁字母的宽度也是不同的,这些差异使得必须有一个全面兼容的方案来解决文字的换行与分页问题。

本项目中的StringLayout便是一个专用于文字布局的类,比较好的解决了上面提到的问题。这是本项目中唯一使用的一个第三方类,原作者将其发布在互联网上并允许他人免费使用或修改。

StringLayout类的原理是在绘制文字前不断测试新加入的字符是否为换行符并根据Font.stringWidth(String str)计算当前行宽度,当检测到当前行宽接近屏宽时则把当前行文字保存在一个Vector中,再继续计算下一行,直至计算完所有文字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int begin = 0;
fontHeight = font.getHeight();
lineCount = 0;
line = new Vector(5, 2);
for (int i = 0; i < text.length(); i++)
{
char ch = text.charAt(i);

if (font.stringWidth(text.substring(begin, i + 1)) >= layoutWidth
|| i == text.length() - 1 || ch == '\n')
{ // layoutWidth-3中的3为偏移值
if (i == text.length() - 1) i++;
line.addElement(text.substring(begin, i));
if (ch == '\n')
begin = i + 1;
else
begin = i;
lineCount++;
}
}

在绘制文字的时候则从指定行开始遍历Vector,读出每行内容并将其画在Graphics上。由于文字是先计算好再保存在Vector中的,不用担心超过会屏高的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 画出字符串
*/
public void draw(Graphics g, int x, int y)
{
int i1 = g.getClipX();
int j1 = g.getClipY();
int k1 = g.getClipWidth();
int l1 = g.getClipHeight();

g.setClip(layoutX, layoutY, layoutWidth, layoutHeight);
g.setFont(font);
for (int i = currLine; i < line.size(); i++)
{
String s = (String) line.elementAt(i);
g.drawString(s, x, y + (i - currLine) * (fontHeight + lineGap),
Graphics.TOP | Graphics.LEFT);
}

g.setClip(i1, j1, k1, l1);
isChange = false;
}

image.png-4.5kB

图 4.5 1 自动换行效果

由于采用了先计算再绘制的方式、而且使用了Vector保存文字排版,该类对于大段的文字显示(如电子书阅读器)显示是不适合的,这将使文字第一次显示的时间相当漫长而且会大量消耗内存。但对于本游戏中少量的文字显示,影响并不大,出于时间的考虑,并没有对它进行重写。

4.6 地图设计

地图是本游戏是的核心部分,除基本的地图背景外还涉及到与精灵的碰撞、与冲击波的互动、道具的生成。

4.6.1 基本结构

因游戏中建筑物被破坏后将消失并露出地表层,因此至少应该有两个贴图层——地表层与建筑层。地表层表示地面贴图,如草地、马路(见)。建筑层是一个广义的概念,表示高出地表层上的建筑、箱子、树木等。地表层是畅通无阻的,仅仅是一个贴图效果,但建筑层具有其自己的属性,如房屋、树木是不可通行且不可摧毁的,箱子与石头不可通行但可被摧毁,而箱子是可被角色推动的,因此还必须有一个用来描述建筑属性的“层”。所有层重叠后形成完整的地图场景。

image.png-85kB

图 4.6 1 地表层

image.png-75.2kB

图 4.6 2 建筑层

image.png-175.9kB

图 4.6 3 地表层+建筑层

游戏的场景是一个由N×N方格拼凑出的世界,可以很容易想到使用二维数组来表示这个场景,每一个数组元素映射一个方格。对于地表层与建筑层,数组元素值表示该方格贴图索引,对于建筑属性层,数组元素值表示对应建筑的属性。最后形成的地图模型是一个三维数组。下面的这个数组就是地图 4.6 3的数据模型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
byte[][][] tiles =
{
{
{ 1, 1, 1, 4, 4, 1, 1, 1 },
{ 1, 1, 1, 2, 2, 1, 1, 1 },
{ 1, 1, 1, 2, 2, 1, 1, 1 },
{ 1, 1, 1, 2, 2, 1, 1, 1 },
{ 1, 1, 1, 2, 2, 1, 1, 1 },
{ 1, 1, 1, 2, 2, 1, 1, 1 },
{ 1, 1, 1, 4, 4, 1, 1, 1 }
},

{
{ 0, 0, 6, 11, 0, 6, 16, 21 },
{ 0, 21, 5, 0, 11, 5, 7, 11 },
{ 6, 16, 6, 11, 0, 0, 21, 16 },
{ 5, 6, 5, 0, 11, 5, 6, 5 },
{ 16, 21, 0, 11, 0, 6, 21, 6 },
{ 11, 8, 5, 0, 11, 5, 16, 0 },
{ 21, 16, 6, 11, 0, 6, 0, 0 }
},

{
{ 1, 1, 0, 6, 1, 0, 2, 2 },
{ 1, 2, 3, 1, 6, 3, 0, 6 },
{ 0, 2, 0, 6, 1, 1, 2, 2 },
{ 3, 0, 3, 1, 6, 3, 0, 3 },
{ 2, 2, 1, 6, 1, 0, 2, 0 },
{ 6, 0, 3, 1, 6, 3, 2, 1 },
{ 2, 2, 0, 6, 1, 0, 1, 1 }
} ,
};

4.6.2 砖块属性

4.6.2.1 属性规则

因建筑类型的不同会使其所在砖块附带不同的属性,而随游戏的进行该砖块的属性又可能产生各种变化,比如建筑的摧毁会使得其变成可通行、冲击波的经过会使之变得致命。为了简化开发,本游戏将所有的地图属性全部放在一个二维数组中。

这里要注意的是,属性是可以叠加的,一个单元格很可能同时具有多个不同的属性。为了可以让一个数字表示N种属性的叠加,本游戏专门设计了一种属性增减方法。该方法原理是用二进制数字的指定位来表示指定属性,当其为1表示具有此属性,为0时表示无此属性。一个Byte是一个8位二进制数,可以同时表示8种属性,对于本游戏卓卓有余。

本游戏属性原子的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 单元格可通行
*/
public static final int CAN_PASS = 1;

/**
* 单元格可破坏
*/
public static final int CAN_DESTROY = 2;

/**
* 单元格可推动
*/
public static final int CAN_PUSH = 4;

/**
* 单元格致命
*/
public static final int DEADLY = 8;

/**
* 单元格存在炸弹
*/
public static final int BOMB = 16;

其对应的二进制位如图 4.6 4所示:

image.png-6.2kB

图 4.6 4 属性对应的二进制位

4.6.2.2 判断属性

当要判断某单元格是否具有某属性时,则取其值与要检测的属性进行与运算,当结果不为0则表示具有该属性,为0则表示无该属性。如图 4.6 5演示的是对房子与箱子进行是否要破坏的判断,如房子属性的第2位为0,与00000010进行与运算后结果为仍0,表示房子不可破坏。箱子属性第2位为1,与00000010进行与运算后结果不为0(正好等于要判断的属性值),表示箱子可被破坏。

image.png-9.4kB

图 4.6 5 对是具可破坏性的判断

Map类中定义了一个专门的方法用于判断指定行指定列是否具有指定属性:

1
2
3
4
5
6
7
8
9
10
11
/**
* 检查属性层指定单元格中是否存在指定属性
*
* @param row 单元格所在行
* @param col 单元格所在列
* @param feature 要检测的属性
*/
public boolean hasFeature(int row, int col, int feature)
{
return ((tiles[Map.LAYER_FEATURE][row][col] & feature) != 0);
}

4.6.2.3 增加属性

要对某单元格增加某属性的话,只须对其指定属性位取或,不管它未计算前是否具有该属性,计算完成后该位的属性一定会改为1。

image.png-2kB

图 4.6 6 为单元格增加致命属性

具体代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 给地图属性层的指定单元格增加指定属性<br>
* 如果原属性已存在指定属性,则不作修改,否则增加指定属性
*
* @param row 单元格所在行
* @param col 单元格所在列
* @param feature 要增加的属性
*/
public void addFeature(int row, int col, int feature)
{
tiles[Map.LAYER_FEATURE][row][col] |= feature;
}

4.6.2.4 删除属性

要对某单元格删除属性则将原属性值减去要删除的属性,但如果对原本不具该属性的属性值进行了减运算,将会影响到其它属性位的值。因此先为其增加属性,再减去该属性,则可以兼容这两种情况。

image.png-2kB

图 4.6 7 直接相减,可能产生非预期结果

image.png-3.2kB

图 4.6 8 先或再减,兼容性两种情况

具体代码实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 给地图属性层的指定单元格消去指定属性<br>
* 如果原属性已存在指定属性,则消去,否则不作修改
*
* @param row 单元格所在行
* @param col 单元格所在列
* @param feature 要消去的属性
*/
public void removeFeature(int row, int col, int feature)
{
tiles[Map.LAYER_FEATURE][row][col] =
(byte)((tiles[Map.LAYER_FEATURE][row][col] | feature) - feature);
}

4.6.3 地图绘制

由于相当部分手机游戏的地图设计都是与本游戏类似的二维数组映射砖块结构,Sun为了简化其绘图过程,在MIDP2.0中发布了TiledLayer类。一个TiledLayer对象容纳了所有的砖块图像资源,并含有一个地图矩阵。所有的砖块图像可以存放在一个大的PNG图片中,通过指定每个tile的宽和高,TiledLayer可以自动分割这些砖块的图像资源。要改变地图单元格只需要使用TiledLayer. setCell(int col, int row, int tileIndex)方法设计该单元格的图像索引,而地图的绘制只须交给其对应的LayerManager处理就行。

image.png-113kB

图 4.6 9 使用TiledLayer生成地图

对于本游戏来说,TiledLayer不仅解决了地图的绘制问题,通过灵活的设计,它还实现了砖块破碎与泡泡爆炸的动画效果,具体将在4.11节介绍。

4.7 资源准备

对于一个游戏来说,丰富的图片元素是必不可少的,特别是原《泡泡堂》就以色彩丰富、角色多样而著称。

4.7.1 图形处理原则

目前几乎所有的手机游戏都使用png格式的图片素材,它是MIDP的标准图片格式,可以保证游戏在各种平台下的兼容性。另一方面来说,png也是最适合作为手机游戏资源的格式,它具有jpg和bmp格式所不具有的透明特性,却又比同样有透明特性的gif格式有更好的色彩表现力。

无论是什么游戏,资源总是占用体积最大的一部分,在手机这种对程序体积有特殊限制的平台上,对资源的使用更加要有节制。为了不让手机游戏的体积超标,一般都使用8位色彩来保存png资源,这也是减小游戏体积的最佳途径。Firework就是一个非常适合用来处理png图片的图形处理工具,它的默认图片保存格式就是png。但是一般采用导出的方式来生成png资源,因为直接保存的png图片会带上路径、层等信息,类似photoshop的psd文件,会大大增加文件体积。

Zip压缩方式压缩一个大图片会比压缩同体积的多个小图片效果好得多,而TiledLayer和Sprite类都是使用一个大图片分割N个砖块的方式,因此更有必要对一些经常同时要用到的图片资源进行合并。基于本游戏的情况,图片合并应该有如下原则:

  • 同一角色的所有姿态应该合并,以供构建Sprite对象
  • 不同的角色图形资源应该分开,以免构建Sprite对象时造成多次浪费
  • 同一图层要用到的砖块图形应该合并,以供构建TiledLayer对象
  • 不同关卡所要用到的地图图形最好分开,以减小不必要I/0
  • 游戏程序图标单独放置

图片的合并并不是简单的对齐拼接,好的拼接规则可以为编程实现极大的方便。应该尽早考虑到各种情况,制定好统一的、有一定适应性的拼图规则,避免编程期可能发生的图形修改。

配合128×128的屏幕分辨率,地图单元格的大小被设定为16×16像素,使用8×8个地砖正好填充整个屏幕。为了让角色看起来有立体的效果,角色的高度大于一个地砖高度,大小被设定为16×24像素。这使得原本正好8×8的地图不得不牺牲一行,以防止出现当角色在屏幕最高一行时头部被摭挡的现象。

image.png-6kB

图 4.7 1 使用8×7地图,防止角色被摭挡

4.7.2 本游戏的图形资源处理

基于以上原则,在前期图形资源处理的时候准备了如下png图片。

image.png-105.5kB

图 4.7 2 图片资源

  • 分别将角色的各种姿态进行了合并
  • 将所有关卡地图的地表层进行了合并
  • 分别将各关卡的建筑层进行了合并
  • 将泡泡与道具进行了合并
  • 其它图片单独放置

4.7.3 声音资源准备

为了兼容所有设备,游戏的声音元素使用了所有MIDP2.0设备可认的mid格式,来源于互联网上下载的《泡泡堂》手机铃声。

4.8 游戏基本原理

在游戏开发行业,经常可以听到“游戏引擎”这个词。这是一个非常恰当的比方,本游戏的工作就像一个引擎一样,除非下达了停止命令,否则它将永远运行下去。

在程序里实现永不停止再简单不过,使用一个没有出口的循环结构就行。我们要做的,就是在这个循环体内告诉游戏该怎么做,这就是游戏的基本工作原理。

1
2
3
4
while(true)
{
doSomething();
}

4.8.1 状态机

现在我们有永不停止的引擎了,只要在doSomething()里告诉机器应该干什么就行。doSomething()可以是一个任务,也可以是多个任务的组合。这些任务可能会很复杂,有的时候我希望它画方,有的时候我希望它画圆。这就需要有一套管理任务的机制,我们可以通过状态变量来实现对引擎发送指令,让机器来进行我想让它做的任务,这就是状态机。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
int state = 1;
while(true)
{
if (state == 1)
{
画方;
}
else if (state == 2)
{
画圆;
}
}

在上面这段程序中先定义了一个state作为状态变量,在循环中会不停的检测它,当发现它为1时就画方,发现它为2时就画圆。我们可以在循环之外人为改变state的值,也可以将state交给循环来控制。至于state等于何值时具体做什么,这完全由开发者事先约定,状态机只会可靠的执行状态变量通知它要执行的任务。

4.8.2 线程

现在已经确定引擎的工作需要一个死循环,但这段循环代码放置的位置必须有一定的讲究。随意加入的死循环将会造成游戏的假死,因为系统没有机会对I/O进行处理。这个循环应该加入一个独立的线程中,与系统主线程并列执行,这样才不会互相干扰。

线程是一种特殊的多任务方式。当一个程序支持多线程时,可以运行两个或更多的由同一个程序启动的任务。Java的特点是内在支持多线程,即使精简到极致的J2ME版本也仍然保留了这一特性。

实现线程有两种方法:继承Thread类或者实现Runnable接口。因为本游戏的Game类已经是GameCanvas类的子类,所以只能使用后一种方法,事实上大多数的游戏都是这样做的。实现了Runnable接口的类必须实现Run方法,这是线程的运行主体,本游戏的“引擎“就在Game类的run()方法中。

在Game对象的构造函数中调用了一个Game.start()方法,它实现了状态机线程的创建和启动。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 开始游戏线程
*/
public void start()
{
isPause = false;

if (thread == null)
{
thread = new Thread(this);
thread.start();
}
}

线程一但被启动,就会自动执行run()方法内的代码,这时游戏才真正的开始。

4.8.3 FPS控制

如同真正的引擎一般,状态机也是有自己的“转速”的,汽车的引擎转速可快可慢,而状态机的“转速”则是越稳越好。
这像一个乐团中所有的演奏者都跟随指挥棒的舞动来演奏自己的乐器,平稳的节奏会产生平稳的音乐,忽快忽慢的节奏会让听众感到不适,假如节奏太快的话也许小担琴手能跟得上,而号手跟不上,演奏就会发生错误。在游戏里,飘忽的节奏会让游戏者感到游戏的下一步无法预料,不好控制,节奏太快的话,也许运算跟得上拍,而I/0却跟不上,这些都是开发者所不想看到的。

因为一般状态机每一次动作都会带来屏幕的刷新,通常用FPS(每秒帧数)来形容状态机的频率。游戏中一般使用Thread.sleep(long time)方法让线程定时休眠来控制FPS。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int spf = 50; //每帧50毫秒
while (true)
{
doSomething();

try
{
Thread.sleep(spf); //线程休眠指定时间
}
catch(Exception ex)
{
ex.printStackTrace();
}
}

以上的代码试图让程序执行完doSomething()方法后休眠50毫秒,以使得产生1000 / 50 = 20FPS的运行频率。但这样是不可靠的,因为doSomething()方法本身会消耗一定的时间,这样每一次节拍的总时间总是会大于50毫秒,而且该方法因为任务内容、系统状态等的不一样消耗的时间将不等,这会让FPS变得忽快忽慢。

我们无法控制doSomething()方法执行的时间,但可以控制线程的休眠时间。只要获知了已执行完的doSomething()方法的耗时,就能通过减少相应休眠时间来让每一次节拍都正好等于预期时间,以此来稳定FPS。经过改进后的FPS控制算法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
int spf = 50; //每帧50毫秒
while (true)
{
// 任务开始时间
long startTime = System.currentTimeMillis();

doSomething();

// 任务消耗时间
long takenTime = System.currentTimeMillis() - startTime;

if (takenTime < spf)// 当执行时间大于spf时放弃休眠
{
try
{
Thread.sleep(spf - takenTime);// 调整休眠时间让总时间等于spf
}
catch (Exception ex)
{
ex.printStackTrace();
}
}
}

FPS越高,游戏地动画效果将越流畅,但手机的性能是有限的,只有手机性能可满足的FPS才是有效的FPS。考虑到各种手机性能的差异,FPS最好设置为可以满足本机性能最低需求的数字,在本游戏中FPS被设置成20,已经可以流畅的表现动画了。

4.9 场景类

本游戏的场景类Game实现了状态机的功能,它同时还是游戏的银幕并且负责接受游戏者的键盘输入。

Game类在初构建的时候只是一个空旷的舞台,待它被通知loadStage(int stageIndex)时它才会开始布置场景。该过程中演员(精灵)、背景(地图)、道具(炸弹)被依次加入。线程一启动后状态机与键盘输入会共同更新各对象的状态,最后通过drawScreen(Graphics g)将所有状态绘制出来。

Game类中最重要的方法是Game.run()与Game.update()。

下面是Game.run()方法是状态机的具体实现,原理在上一节已经介绍过,这里只贴出具体的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
/**
* 线程运行
*/
public void run()
{
while (true)
{
// 开始绘制时间
long startTime = System.currentTimeMillis();

// 时钟计时
if (clock < 10000) // 防止溢出
clock++;
else
clock = 0;

if (!isLoading && !isPause)
{
update();
keyAction(); // 接收键盘输入
}

drawScreen(g);

// 绘制消耗时间
long takenTime = System.currentTimeMillis() - startTime;

/* FPS控制
* 使用SPF(每帧所用时间)控制绘制速度,规定一个SPF值,
* 当上一绘制时间未超过设定SPF,则线程休眠SPF-上帧时间,
* 否则立刻进行下一次绘制
*/
if (takenTime < PopTang.GAME_MSPF)
{
try
{
Thread.sleep(PopTang.GAME_MSPF - takenTime);
}
catch (Exception ex)
{
ex.printStackTrace();
}
}
}
}

Game.update()方法负责各对象的更新,同时还管理着玩家的重生与无效NPC及炸弹的回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
/**
* 更新状态<br>
* 更新精灵,地图,炸弹等
*/
private void update()
{
// 英雄重生
if (!hero.isEnabled)
{
hero = null;
hero = Character.createCharacter(Character.HERO, stage.heroPosition[0], stage.heroPosition[1]);
layerManager.insert(hero, 0);
}

// 更新英雄
hero.update();

// 更新敌人
Enumeration enu = enemies.elements();
while(enu.hasMoreElements())
{
Character enemy = (Character)enu.nextElement();
if (enemy.isEnabled)
{
enemy.update();
}
else
{
enemies.removeElement(enemy);
enemy = null;

// 如果敌人杀光了则进入下一关
if (enemies.size() == 0)
{
nextStage();
}
}
}

// 更新地图
map.update();

// 更新炸弹
enu = bombs.elements();
while(enu.hasMoreElements())// 遍历炸弹集合
{
Bomb bomb = ((Bomb)enu.nextElement());

if (bomb.isVisible)
{
bomb.update();
}
else
{
String key = "" + (bomb.col * 10 + bomb.row);
bombs.remove(key);// 移除不可用的炸弹
bomb = null;
}
}
// 更新NPC
}

4.10 图层

在设计游戏的图层前,首先要思考本游戏到底要绘制多少种图形,再看它们中有哪些摭挡关系。已知地图就包括了地表层与建筑层,再加上炸弹、道具、角色(包括一个玩家控制角色和一至多个NPC),至少有6种图形。地表层在最下面,成为独立一层;角色作为Sprite对象必然各自有自己的图层;建筑层因关卡不同会有不同的图片内容,也独成一层;炸弹、道具在每一关卡都会用到,而且之间没有摭挡关系,可以考虑合并为一层。这就形成了本游戏的图层结构。

image.png-60.4kB

图 4.10 1 游戏图层

为了方便地管理所有图层,游戏使用了一个LayerManager对象,通过LayerManager.append(Layer l)方法可以给LayerManager添加要管理的图层对象,并且这些层会按照添加的先后次序层叠起来,最后被加入的图层会被放在最下方。本游戏图层创建过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
map = null;
map = new Map(stage);

hero = null;
hero = Character.createCharacter(Character.HERO, stage.heroPosition[0], stage.heroPosition[1]);

layerManager = null;
layerManager = new LayerManager();
layerManager.append(hero);// 添加主角层
loadPercent = 30;

// 加入敌人
int i = 0;
while (stage.enemyPosition[i] >= 0)
{
if (i % 2 == 1)
{
Character enemy = Character.createCharacter(Character.ENEMY,
stage.enemyPosition[i - 1], stage.enemyPosition[i]);
enemies.addElement(enemy);
layerManager.append(enemy);// 添加敌人层
}
i++;
}
loadPercent = 40;

itemsLayer = map.getBombsAndToolsLayer();
floorLayer = map.getFloorLayer();
buildingsLayer = map.getBuildingsLayer();

layerManager.append(buildingsLayer);// 添加建筑层
layerManager.append(itemsLayer);// 添加道具层
layerManager.append(floorLayer);// 添加地表层

4.11 炸弹

本游戏设计了一个Bomb类来作为炸弹的抽象,该类即不是继承自Sprite也非TiledLayer,它只实现一个炸弹模型,绘图则是通过修改炸弹层(兼道具层)单元格来实现,所有的炸弹共享同一个炸弹层。

炸弹有一些特有的属性,如可视性、位置、威力、延时、放置者及状态,有自身爆炸、引爆其它炸弹、清除爆炸效果等,下面将逐一介绍。

4.11.1 创建与回收

炸弹由角色施放而产生,随爆炸效果的完成而消失。屏幕上最大炸弹的数目取决于角色数量及各角色的携弹量,屏幕上可能没有一个炸弹,也有可能每个单元格都有一个炸弹,应该有一个适当的容器来存放它们,显然这个容器的大小应该是可变的。

J2ME里可选的容器类有java.util.Vectorjava.util.Hashtablejava.util.Stack,本游戏选择了HashtableHashtable集合中的元素以Key/Value方式存在,Key用来快速查找;Value用于存储对应于Key的值。当炸弹对象被角色创建后,炸弹的位置作为Key值与炸弹一同被存入Hashtable。这样做可以让程序可以根据位置获得炸弹,便于炸弹的互相引爆。

1
2
String key = "" + (getCol() * 10 + getRow());
Game.bombs.put(key, new Bomb(this));

创建炸弹的同时还须要做两件事:将炸弹所在的单元格设为不可通行并标注为已放置炸弹,已存在炸弹的单元格将不允许被再次放置炸弹。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 地面设为不可通过
map.removeFeature(row, col, Map.CAN_PASS);

// 地面加注炸弹属性
map.addFeature(row, col, Map.BOMB);

当炸弹完成爆炸后,它会将自身的isVisiable属性设置为false。在状态机对爆炸进行状态时会检测该属性,发现它为false时则将该炸弹从Hashtable中移除并将其回收。
// 更新炸弹
enu = bombs.elements();
while(enu.hasMoreElements())// 遍历炸弹集合
{
Bomb bomb = ((Bomb)enu.nextElement());

if (bomb.isVisible)
{
bomb.update();
}
else
{
String key = "" + (bomb.col * 10 + bomb.row);
bombs.remove(key);// 移除不可用的炸弹
bomb = null;
}
}

4.11.2 更新状态

炸弹被放置后会在状态机里不停调用Bomb.update()方法更新自身的状态。在这个方法中,炸弹首先会对自己的计时进行累加,然后将计时与预先设定的延时相比较,发现计时已经超过延时时则将自身设置为爆炸状态。

1
2
3
4
5
6
7
clock++;

// 检测是否要开始爆炸
if (clock > Bomb.EXP_DELAY)
{
status = Bomb.STATE_EXPLODE;
}

接下来根据炸弹的状态来让炸弹做它应该做的事。

首先检测炸弹是不是正常态,正常态下炸弹有一个跳动的动画效果,该动画共有4帧(见图 4.11 1首行),每隔一定时间循环切换。切换时机由计时器对10取余来产生,帧的切换则由对原帧余4加1实现。

image.png-7.5kB

图 4.11 1 泡泡与道具层

1
2
3
4
5
6
7
8
9
if (status == Bomb.STATUS_NORMAL)
{
// 炸弹稳定态动画
if (Game.clock % 10 == 0)
{
itemsLayer.setCell(col, row, itemsLayer.getCell(col, row) % 4 + 1);
}
return;
}

这里要注意到产生时机的计时器并不是Bomb.clock而是Game.clock。之所以这样是因为当屏幕上有多个炸弹时,各炸弹的因为创建时间不同,使用Bomb.clock产生动画切换会造成跳动节奏的不一致,影响视觉效果。而使用Game.clock作为产生跳动的时钟可以让全屏的炸弹跳动一致,效果要生动得多。

由于正常态处理中使用了return退出update方法,接下来的更新只针对于爆炸效果。炸弹爆炸后冲击波将延迟4个时间片,检测到已达到4个时间片将清除爆炸效果并退出本次更新。

1
2
3
4
5
6
// 是否完成爆炸, 清空爆炸效果
if (explodeClock >= 4)
{
clearExplode();
return;
}

如果炸弹的状态不属性前两种的话,那此时只可能为爆炸状态,由explode()方法负责状态更新。

1
2
3
4
5
if (status == Bomb.STATE_EXPLODE)
{
// 爆炸效果
explode();
}

4.11.3 爆炸

炸弹的爆炸方法要进行以下三项工作:

  • 绘制爆炸效果
  • 为爆炸范围的覆盖的所有单元格附加致命属性
  • 引爆爆炸范围内的其它炸弹

在具体介绍爆炸方法前先要介绍一下Bomb类的int explodeClock变量,它是炸弹的爆炸计时器,用来记录爆炸开始后经过的时间片,每一次explode()方法的调用它都将进行累加,它被用作于爆炸效果的动画帧索引和爆炸完成的标记。

Bomb.explode()方法首先改变爆炸中心的爆炸效果,并将该单元格标为致命。

1
2
itemsLayer.setCell(col, row, 4 + explodeClock);
map.addFeature(row, col, Map.DEADLY);

接下来更新4个方向上的冲击波,每一个方向上都使用循环对爆炸范围内的单元格进行遍历,对可通行的单元格标注为致命并进行冲击波绘制,当发现该方向上某单元格无法通行时则通知Map对象对其进行摧毁并中断循环。

Map.destroy(int row, int col)方法会摧毁指定单元格内的建筑,事实上它只是充当了引爆的作用,将建筑由正常帧转换到了半破坏帧。而Map的update()方法会检测到它的半破坏状态,并“将破坏进行到底”。如果指定行列上的建筑不具有可摧毁性或不存在建筑,调用该方法将返回false。

除了摧毁阻拦的建筑外,爆炸冲击波还将引爆它范围内的所有炸弹,这将交由Bomb.detonate(int row, int col)来处理。

下面是炸弹右方向上冲击波效果的代码实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 右方冲击波
for (int i = 1; i <= power; i++)
{
// 是否超出边界,超出则不检测
if (col + i < PopTang.MAP_COLS)
{
// 引爆该单元格
detonate(row, col + i);

// 单元格是否可通过
if (map.hasFeature(row, col + i, Map.CAN_PASS))
{
// 设置单元格为致命
map.addFeature(row, col + i, Map.DEADLY);

if (i != power)// 普通冲击波
itemsLayer.setCell(col + i, row, 8 + 3);// 绘制冲击波
else
// 最后一波
itemsLayer.setCell(col + i, row, 20 + explodeClock);
}
else
{
map.destroy(row, col + i);
break;
}
}
}

4.11.4 引爆

炸弹可以互相引爆的特性趋于真实,也使得游戏的复杂大大增加,至今仍有很多《泡泡堂》的玩家在反复试验如何利用这一特性更快速有效的杀死对手。

如何确定冲击波范围内有没有炸弹,并准确的将其引爆呢?现实中冲击波并不关心这个问题,它只会将温度与压力带到爆炸范围内的每一个角落。本游戏的引爆方式也与之类似,explode()方法对冲击波范围内的所有单元格进行引爆,是否成功取决于单元格内是否存在炸弹,这可以对单元格的属性进行检查得知。如果map.hasFeature(row, col, Map.BOMB)返回为true的话,通过行列值组合产生的Key去Hashtable中检索可以准确得到该单元格的炸弹对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 引爆单元格炸弹<br>
* 如果指定单元格内不存在炸弹,则退出;
* 如果指定单元格内存在炸弹,则引爆
*
* @param row 要引爆的行
* @param col 要引爆的列
* @return 是否引爆成功
*/
private boolean detonate(int row, int col)
{
// 是否触发其它炸弹
if (map.hasFeature(row, col, Map.BOMB))
{
String key = "" + (col * 10 + row);
Bomb bomb =(Bomb)Game.bombs.get(key);
if (bomb != null)
bomb.status = Bomb.STATE_EXPLODE;
return true;
}
else
{
return false;
}
}

炸弹引发的过程实现类似于摧毁建筑,只要通知被引爆的炸弹改为爆炸状态就行,该炸弹下一次被update的时候将自行产生爆炸效果。因为炸弹是逐个进行update的,假如引爆的炸弹在本次时间片内还未遍历到的话,它将立刻产生爆炸;而假如引爆的炸弹在本周期内已经被遍历过了,爆炸将推迟到下一个周期。这种差异会造成50ms内引爆效果的不可预计,但难以被游戏者察觉,可以忽略。

4.11.5 清除爆炸效果

爆炸完成后需要清除爆炸造成的影响,将炸弹的isVisible属性设置为false,以使状态机对其自动回收;将炸弹设置者的炸弹携带量加1;去除原炸弹所在的单元格的炸弹属性、恢复通行;去除冲击波覆盖单元格去的致命属性;去除所有炸弹动画帧。
与产生爆炸一样,对冲击波影响的清除也需要对四个方向进行循环遍历。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
isVisible = false;
parent.bombsNum ++;

itemsLayer.setCell(col, row, 0);

// 去除炸弹
map.removeFeature(row, col, Map.BOMB);

// 去除致命
map.removeFeature(row, col, Map.DEADLY);

// 恢复通行
map.addFeature(row, col, Map.CAN_PASS);

for (int i = 1; i <= power; i++)
{
// 检测右方
if (col + i < PopTang.MAP_COLS)
{
clearShockwave(row, col + i);
map.removeFeature(row, col + i, Map.DEADLY);
}
}
……
……

由于炸弹与道具共享一个图层,清除爆炸动画的时候要区分对待,这里使用了一个clearShockwave(int row, int col)方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 清除指定单元格冲动波<br>
* 由于炸弹层与道具层共享,所以当单元格为道具时,不予处理,否则清零
*
* @param row 单元格所在行
* @param col 单元格所在列
*/
private void clearShockwave(int row, int col)
{
if (itemsLayer.getCell(col, row) > 4
&& itemsLayer.getCell(col, row) < Map.TOOL_BOMB)
{
itemsLayer.setCell(col, row, 0);
}
}

4.12 道具

建筑被摧毁后,有可能在原地产生一个随机道具。int Map.randomTool()函数被用来实现此功能,概率由PopTang.PopTang.TOOL_PROBABILITY阀值决定的。这个常量可设置的范围为0~100,正好对应产生道具的百分比概率。
系统首先会生成一个0~100的int型随机数,将它与概率阀值相比较,如果大于阀值则直接返回0表示无道具,否则进入挑选随机道具流程。
4种道具出现的概率是相等的,这由一个[0, 4)间的随机整数决定。道具的出现与绘制只需设置道具层的指定位置为道具帧即可,剩下的交由其它对象来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/*
* 产生随机道具
*/
private int randomTool()
{
int probability = rand.nextInt(100);
if (probability >= PopTang.TOOL_PROBABILITY)
{
return 0;
}

int rst = rand.nextInt(4);
rst += Map.TOOL_BOMB;
return rst;
}

4.13 角色

角色自身的状态比较复杂,而且几乎与游戏场景的所有对象都有交互,是本游戏的一大核心。

image.png-17kB image.png-14.9kB

游戏场景并不关心角色的内部实现,它只关心角色的创建、更新、移动、放置炸弹这几个功能,下面将逐一介绍。

4.13.1 创建

目前角色有主角和NPC两种,角色类里定义了一个静态方法Character createCharacter(int characterType, int row, int col)来创建不同的角色。这里应用了一个工厂方法模式,使得Game场景不用对角色的创建担负责任,只须通知角色类我需要使用谁就可以,这种模式在本游戏里有多处被使用到。
实际上主角和NPC的创建只有精灵源图片不相同,其它的参数是一样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 创建精灵
* @param characterType 精灵类型
* @param row 精灵出生时所在行
* @param col 精灵出生时所在列
* @return 精灵对象
*/
public static Character createCharacter(int characterType, int row, int col)
{
Image imgCharacter = null;
try
{
switch(characterType)
{
case Character.HERO:
imgCharacter = Image.createImage(PopTang.IMAGE_SRC_HERO);
break;
case Character.ENEMY:
imgCharacter = Image.createImage(PopTang.IMAGE_SRC_ENEMY);
break;
default:
break;
}
}
catch (IOException ex)
{
ex.printStackTrace();
}
return new Character(imgCharacter, row, col);
}

4.13.2 更新

在角色更新状态之前,它需要先对自己类型进行判断,当发现自己是NPC的时候,让自身先通过AI流程产生动作,再返回来继续更新。由于时间问题,本游戏AI只是产生一个简单的随机动作,就不详细介绍了。

1
2
3
4
if (type == Character.ENEMY)
{
aiAction();
}

如果角色状态为生存的话,接下来将对自身所处的环境进行检查。先检查所处单元格是否存在道具,是的话则附加该道具属性并将地面清空。

1
2
3
4
5
6
7
// 拾获道具
int tool = map.getBombsAndToolsLayer().getCell(col, row);
if (tool >= Map.TOOL_BOMB)
{
pickUpTool(tool);
map.getBombsAndToolsLayer().setCell(col, row, 0);
}

然后检测所处单元格是否致命,是的话立刻转入死亡状态,并返回等待下一次更新。

1
2
3
4
5
6
// 检测所在单元格是否致命
if (map.hasFeature(row, col, Map.DEADLY))
{
isAlive = false;
return;
}

剩下的事情就是对角色状态的绘制与更新了。

角色一开始被创建的时候是处于出生状态,这段时间内角色在原地做旋转动作。旋转动画为角色资源图片的1-5帧,每2个时间周期切换一次。当发现角色年龄大于出生期PopTang.BORN_TIME时,角色转入静止状态,朝南站立。

1
2
3
4
5
6
7
// 由出生状态转静止状态
if (status == Character.BORN && age > PopTang.BORN_TIME)
{
status = Character.FACE_SOUTH;
setFrame(Character.FACE_SOUTH * 5);
return;
}

角色进入静止状后计时器stayTime开始累加,当超过阀值PopTang.REST_WAITTIME时角色进入休息状态。休息状态的角色有一个眨眼睛的动画,为了让角色眨眼睛的频率显得更加自然,眨眼动画帧的切换时机由stayTime计时器多次取余产生。

1
2
3
4
5
6
// 休息状态
if (status == Character.REST)
{
if ((stayTime/5) % 15 == 0 || (stayTime/5) % 20 == 0)// 眨完眼睛后停一会
setFrame(Character.REST * 5 + (getFrame() + 1) % 5);
}

下面处理角色的死亡状态,与休息状态转换一样同样有一个deadTime计时器用来累加角色的已死亡时间。在deadTime小于阀值PopTang.DEAD_WAITTIME的期间角色将播放死亡动画,并每隔4个周期将自身的isVisible属性取反,造成闪烁效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 是否超过死亡时间,人物失效
if (deadTime < PopTang.DEAD_WAITTIME)
{
deadTime++;
}
else
{
isEnabled = false;
setVisible(false);
return;
}

// 正在死亡
if (age % 2 == 0)
{
// 修改姿态
int preFrame = getFrame();
int nextFrame = 0;
if (preFrame < Character.DEAD * 5 || preFrame >= (Character.DEAD + 1) * 5)
{
nextFrame = Character.DEAD * 5;
}// 转换姿态
else
{
if (preFrame != ((Character.DEAD + 1) * 5) - 1)
nextFrame = Character.DEAD * 5 + preFrame % 5 + 1;
else
{
nextFrame = preFrame;
if (age % 4 == 0)
setVisible(false);
else
setVisible(true);
}
}// 继续前一姿态
setFrame(nextFrame);
}
return;

死亡时间超过阀值后角色将转入不可用状态,isEnabled属性被改为false,状态机将会定时检查该属性,以对角色进行重生或回收。

4.13.3 移动

角色的移动指令来源于键盘的输入或者AI指令,为了让游戏操作更加的流畅,角色移动还应该具有模糊对齐的特性。总之,此处要解决的最大问题是与地图的碰撞。

角色大小是16×24像素,但它的有效碰撞区域为16×16像素,与地图单元格大小一样,这须要在计算碰撞的时候进行适当的转换。下面用图例逐一介绍碰撞的几种情况,在图中我们以黑色方格表示障碍,红绿方格表示角色,其中红色方格表示角色的有效区域:

  • 前方无障碍,直接通行

    角色向某个方向移动前先要判断当移动后该方向上的两个顶点是否会落在障碍区域内,如果不会的话则向该方向移动speed个像素。

    image.png-3.8kB

    图 4.13 3 向前移动speed像素

  • 前方近距离有障碍,贴近障碍物

    当检测前方有障碍并且与障碍的距离小于speed时则贴近障碍单元格。

    image.png-4.4kB

    图 4.13 4 贴近障碍单元格

  • 前方有障碍,但只有小部分碰撞,绕开障碍物

    当检测到前方有障碍,但障碍只与角色的一小部分发生碰撞时,角色将绕开障碍单元格,这种模糊对齐的特性使得角色操作变得更加流畅。

    image.png-3.5kB

    图 4.13 5 绕开障碍,模糊对齐

角色移动与碰撞的代码篇幅较长,此处不再贴出,可参看附录源码。

进行完角色移动后需要更新角色动画帧,该帧由角色前一帧与当前方向换算得出。

1
2
3
4
5
6
7
8
9
10
11
// 修改姿态
int nextFrame = 0;
if (preFrame < direction * 5 || preFrame >= (direction + 1) * 5)
{
nextFrame = direction * 5 + 1;
}// 转向
else
{
nextFrame = direction * 5 + 1 + (preFrame - direction) % 4;
}// 继续前一姿态
setFrame(nextFrame);

4.13.4 设置炸弹

角色的最后一个方法是放置炸弹,其基本流程已在第4.11.1节介绍过,不再累述,这里要注意判断当前单元格及当前角色是否允许放置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* 放置炸弹
*/
public void setBomb()
{
// 检查单元格中是否已放置炸弹
if (!map.hasFeature(getRow(), getCol(), Map.BOMB))
{
// 当角色为生存状态且携弹量大于0时才可放置
if (isAlive && bombsNum > 0)
{
String key = "" + (getCol() * 10 + getRow());
Game.bombs.put(key, new Bomb(this));
bombsNum --;
}
}
}

4.14 播放声音

一个精致游戏除了有优秀的动画效果外,声效也是不可缺少的。由于声音的处理难度较高,本游戏只采用了两个简单的mid音乐来作为游戏背景音,即使这样,也使得游戏效果增加了不少。

声音的播放是在Game类中实现的,相比起无声的MIDP1.0MIDP2.0只须使用廖廖几行便可以播放MID音效。

1
2
3
player = Manager.createPlayer(getClass().getResourceAsStream("/mid/" + stage.midIndex + ".mid"), "audio/midi");
player.setLoopCount(-1);// 音乐无限循环
player.start();// 播放

4.15 资源装载与进度条

在游戏开始之前Game类总是会将本关卡要使用的对象先创建好,以免出现临到使用的时候才创建它可能产生的空指针异常。无论是PC还是手机,在进行大量资源加载的时候都需要一定的时间,而此时用户能做的只有等待。长短不可预知的等待很容易让玩家失去耐心,这将降低玩家对游戏的评价。因此无论是PC还是手机,在游戏加载的时候都很有必要在屏幕上绘制一个进度条来告诉玩家当前的加载情况。

如何一边加载资源一边绘制加载进度呢?有了状态机的经验后很容易想到多线程。资源的加载是场景类Game来完成的,但它为了充当游戏的状态机,已经实现了一个Runnable接口,本身就是一个新的线程,因此我们必须再建一个其它的线程类来满足这一要求。

因为资源装载线程与场景类结合非常紧密,最的好的解决方案是在Game类中建立一个内部类以实现与Game对象的资源共享。该类被命名为Loading,实现了一个Runnable接口,它被创建后就立即启动,在run()方法中将isLoading标记被设为true,然后调用Game.loadState(int stateIndex)进行资源装载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 资源装载类<br>
* 创建一个内部线程来装载资源
* @author caipiz
*/
class Loading implements Runnable
{
// 内线程
Thread innerThread = null;
int stageIndex = 1;

public Loading(int stageIndex)
{
this.stageIndex = stageIndex;
innerThread = new Thread(this);
innerThread.start();
}

public void run()
{
isLoading = true;
loadStage(stageIndex);
System.gc();
isLoading = false;
}
}

资源装载其实就是各种对象的创建的过程,这里定义了一个loadPercent变量来标记进度,它的初始值为0,每成功创建一个对象后会进行累加,加载完所有对象后它变成100。

这时状态机因为检测到isLoading为true,drawScreen(Graphics g)方法并不会绘制游戏画面,而是在内部调用drawLoadingFrame(Graphics g)方法,根据当前loadPercent的值绘制一个进度条。资源完成加载时isLoading被改为false,此时游戏才真正的可以开始了。

4.16 关卡

至此,《泡泡堂》手机版的引擎已经可以算是搭建完毕,但还没有一个可玩的关卡,仍然不能开始游戏。

为了提高游戏的扩展性,本游戏的关卡布局是通过文件配置实现的。新增关卡只需要加入所需的图片和声音资源,再增加一个关卡配置文件即可。也可以重复利用已有的图片和声音资源,只增加一个新布局的关卡配置。

一个关卡的信息包括:

  • 关卡要使用的图片资源文件序号
  • 关卡要使用的声音资源文件序号
  • 关卡地图布局
  • 主角的出生位置
  • 敌人数目
  • 敌人的出生位置

在Java里很容易定义一个数据结构来存放上述信息,我们只需要构建一个关卡信息类Stage,并对该类进行持久化,保存成一个关卡配置文件即可。

4.16.1 保存关卡配置

可惜J2ME精简了J2SE的序列化接口,使得不能直接将Stage类保存并写入文件。但Stage数据类型比较简单,排列也比较固定,可以人工的对该类进行序列化持久保存。

已定义的Stage类有如下属性:

1
2
3
4
5
6
public int stageIndex = -1;
public int imgIndex = 1;
public int midIndex = 1;
public byte[][][] mapData = new byte[3][40][8];
public byte[] heroPosition = new byte[2];//英雄出生位置,英雄所在的行和列
public byte[] enemyPosition = new byte[10];// 敌人出生位置
  • int stageIndex是关卡序号,是作为配置文件的文件名保存的。
  • int imgIndex是图片文件序号
  • int midIndex是声音文件序号
  • byte[][][] mapDate是大小为3×7×8的字节型数组,用来保存地图布局
  • byte[]heroPosition是一个大小为2的字节型数组,这两个元素分别保存主角出生的行和列。
  • byte[] enemyPosition是一个大小为10的字节型数组,每两个连续的元素可以用来表示一个NPC的出生位置,元素值为-1则表示不需要该NPC。它满足了同时保存敌人数目和敌人位置的需求。

因为J2ME不支持文件写入,配置文件的生成须建立一个J2SE工程来实现。写配置文件的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* 存储地图数据
* @param stage 要保存为的关卡
* @throws IOException
*/
public void save(int stage) throws IOException
{
FileOutputStream os = new FileOutputStream("stage/" + stage);
DataOutputStream dos = new DataOutputStream(os);

// 写入地图贴图文件索引
dos.writeInt(imgIndex);

// 写入音乐文件索引
dos.writeInt(midIndex);

// 写入地图数据
byte[] map = new byte[3 * 7 * 8];
int index = 0;
for(int i = 0; i < 3; i++)
{
for(int j = 0; j < 7; j++)
{
for(int k = 0; k < 8; k++)
{
map[index++] = mapData[i][j][k];
}
}
}
dos.write(map);// 一次性将整个地图读入一维数组中

// 写入英雄出生点
dos.write(heroPosition);

// 写入敌人出生点
dos.write(enemyPosition);

// 将数据写出文件
dos.flush();
}

这个方法将Stage类的所有属性有依次写入了文件当中,使用十六进制编辑器查看关卡文件很容易找到与属性的对应关系。

image.png-15.6kB

图 4.16 1 关卡配置文件结构

4.16.2 读取关卡配置

游戏在读取关卡配置的时候只需要按照保存时的顺序,将对应的类型一个个读入Stage对象中即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
/**
* 获取指定关卡地图数据
* @param stage 关卡号
* @return 关卡数据对象
* @throws IOException 未找到对应关卡数据文件则抛出异常
*/
public static Stage loadStage(int stageIndex) throws IOException
{
Stage stageData = new Stage();
stageData.stageIndex = stageIndex;
DataInputStream dis = new DataInputStream(Stage.class.getResourceAsStream("/stage/" + stageIndex));

// 读取地图贴图文件索引
stageData.imgIndex = dis.readInt();

// 读取地图贴图文件索引
stageData.midIndex = dis.readInt();

// 读取地图数据
byte[] map = new byte[3*7*8];
dis.read(map);// 一次性将整个地图读入一维数组中
int index = 0;
for(int i = 0; i < 3; i++)
{
for(int j = 0; j < 7; j++)
{
for(int k = 0; k < 8; k++)
{
stageData.mapData[i][j][k] = map[index++];
}
}
}

// 读取英雄出生点
dis.read(stageData.heroPosition);

// 读取敌人出生点并获得敌人数目
dis.read(stageData.enemyPosition);

return stageData;
}

4.16 2为本游戏中三个关卡的效果,其中第一关与第二关是共享资源图片的。

image.png-29.6kB

图 4.16 2 不同关卡效果

4.17 兼容性设计

虽然Java号称“Write Once, Run Everywhere”,本文开始也提到它的这一特性使得J2ME在手机平台上迅速推广。但因为手机品牌、型号成千上万,想完全“Write Once, Run Everywhere”是不可能的,我们只能在开发J2ME应用的时候,应可能能的考虑到平台的差异性,将可能发生的不兼容情况减到最少。

下而本人对手机平台兼容性设计的一些理解:

  • 屏幕分辨率

    手机游戏一般在开发之前就确定了它的最小分辨率,如果仅针对这一特定的分辨率编写代码的话,到测试的时候很可能会发现它在其它大分辨率的机型上绘图惨不忍睹,不得不从头开始修改。事先就想好在各种分辨率下绘图可能产生的效果,并设计出兼容性方案的话,开发成本会比前者小得多。

    对于兼容其它分辨率,可以两种办法。一是不为所动,只坚持预先设计的布局。例如本游戏,不管设备分辨率多大,只居中显示128×128的游戏画面。另一种方法是扩展显示区域,显示更多的内容。对于一些卷轴类游戏,这种设计会产生很好的效果,本游戏的毕设信息Splash也采用了这种设计。

    不管采用上述的哪种方法,都应该采用相对坐标系统来绘图,这是以不变应万变的最佳方法。

  • 字体

    不同手机的字体大小很可能不一样,它们的字符集也可能有差异(比如诺基亚手机里换行符是不可见的,而索爱手机却能够显示出来)。在Canvas上绘制文字时应使用Font.getHeight()来调整文字的行距,使用Font.stringWidth(String s)或Font.charWidth(char c)来自动调整文字的换行。某些情况下也可以考虑用一系列图片来替代文字显示,如计分牌。

  • 键盘keyCode

    因为很多手机的keyCode映射不一样,应避免直接使用keyCode值来判断按键,而是使用如Canvas.Up、Canvas.DOWN这样的枚举来替代。

  • 运算、I/0性能

    想让自己的游戏在各种性能不一的手机上都能正常运行的话,唯一的办法就是降低程序对平台的要求。

  • 扩展API

    很多手机都提供了除标准简表外的扩展API,如Nokia UI、MMAPI、JSR75等等,这些API使得手机程序可以提供更强的功能,但使得程序的兼容性大大降低。想自己的程序可以运行在更多平台上的话,就应该尽量只使用标准的类库。一些开发者为了游戏有更好兼容性,仍然在使用了MIDP1.0类库。

  • 某些机型的Bug

    很多手机虚拟机都有bug,有些bug甚至出现在一系列手机上,使得我们开发J2ME应用的时候不得不考虑它们。这要求开发者对各种手机的bug都有所了解,在编写代码的时候尽量避开。

4.18 打包与混淆

EclipseME插件提供了直接将程序打包成JAR的方法,并同时生成与之配合的JAD文件。如果指定了混淆器的话,它还能将源代码经过混淆后再打包。

如图 4.18 1,对项目点击右键,选择J2ME,在展开的下一级菜单中选择Create Package可以制作普通JAR包,选Create Obfuscated Package可以制作混淆JAR包(前提是要在EclipseMe选项中设置好混淆器)。生成的JAR和JAD文件将保存在项目文件夹的deployed目录下。

image.png-116.8kB

图 4.18 1 使用EclipseMe混淆打包

Java程序被编译后生成的.class文件是字节码文件,使得其很容易被反编译成Java源码,这让Java编译出来的东西很不安全。混淆器就是为此而生,经过混淆后的程序反编译出来代码将变成难以阅读,增加了代码被窃用的难度(其实作用有限)。混淆的主要原理是将源码中的长变量名替换成了简短的字母,这样带来的一个副作用是编译后的.class文件会比原来更小,对于对程序体积要求严格的手机来说,这多出来的几K可能至关重要的。

现在的混淆器除了上面提到的替换变量名外,还会对代码进行一些优化。如本项目中用到的ProGuard,除了在压缩操作删除的无用类、字段和方法外,也能在字节码级提供性能优化,内部方法有:

  • 常量表达式求值
  • 删除不必要的字段存取
  • 删除不必要的方法调用
  • 删除不必要的分支
  • 删除不必要的比较和instanceof验证
  • 删除未使用的代码
  • 删除只写字段
  • 删除未使用的方法参数
  • 像push/pop简化一样的各种各样的peephole优化
  • 在可能的情况下为类添加static和final修饰符
  • 在可能的情况下为方法添加private, static和final修饰符
  • 在可能的情况下使get/set方法成为内联的
  • 当接口只有一个实现类的时候,就取代它
  • 选择性的删除日志代码

第5章 总结

2004年10月我拥有了我第一部支持Java程序的手机——NOKIA3108,第一次发现原来手机也可以像电脑一样运行程序,而且程序资源是如此的丰富。接下来的时间最常用到的功能还是利用手机来看电子书,2005年9终于因为对现有电子书软件的不满而开始学习J2ME,试图写一个自己的阅读程序,最后我成功了,而且发现有很多人喜欢它,是J2ME让我第一次产生了作为一个程序作者的骄傲。

后来因为专业的原因,我并没有在手机开发方面发展下去,而是选择了J2EE方向。没想到在最终做毕业设计的时候,因为万老师的认可,让我又回到了J2ME,真的是很有戏剧性。

作为我大学学习里的最后一个“作业”,我非常看重自己的毕业设计,希望把它做成我这四年里写得最精妙一个软件,运用到每一个优秀的手机游戏应该用到的所有技术,再用深度遍历的方式,写一篇详细的论文。将这些作为对我四年软件学习的一次总结,也是对大学生活的最后留念。于是我很用心地去学、去查、去问、去做、去写,这期间我获得了不小的进步,加深了对手机游戏的理解,提高了对图形处理软件和Word的熟练度,我甚至有想过以后从事J2ME这个行业。可惜最终因为时间的问题,我没有做到我希望的那样好。

在程序方面,现在的游戏结构还应该还可以改进;CPU与内存还有很大优化的空间;PNG素材应该可以加密并压缩到更小;游戏流程应该可以更加完整;AI应该可以更加聪明;推箱子功能也应该实现。

在论文方面,还有很多的技术点没有介绍到;已介绍的技术也应该可以讲得更加详细;图片应该可以做得更加丰富和精致。特别是文字,四年的训练让我的代码越写越流利,文字却越来越生疏。

临近毕业,以上诸般的遗憾无论是我想与不想,都将无法再改变了,但其中仍然有我值得欣慰的地方。这是我的第一个手机游戏作品,虽然没有完全完成,但我现在做到的程度完全证明了我有做好它的能力。论文虽然写得不够详尽,但里面每一个字都是我自己一个一个敲打出来的。为了造就这一个几十k大的程序和几万字的文章,我参考了一百多兆的资料、浏览了不计数的网页、处理了几百张图片,前后动用了十数种软件工具。至少,我努力过了!

参考文献(Reference)

1 詹建飞. J2ME开发精解[M]. 北京:电子工业出版社,2006
2 黄聪明. Java移动通信程序设计–J2ME MIDP[M]. 北京:清华大学出版社,2002
3 荣钦科技. Java2游戏设计 [M]. 北京:清华大学出版社,2004
4 百宝箱业务应用程序开发规范-Java分册 [S].中国移动通信集团公司,2003
5 Jason Lam著 Deaboway Chou译. J2ME & Gaming(J2ME游戏开发) [M].2004
6 王森. Java手机程序设计入门 [M]. 知城数字科技股份有限公司,2001

致谢

一次毕业设计并不仅仅代表着大四下学期几个月努力而得到的成果,它是对分析问题解决问题的一次挑战,更是对四年学习积累的一次检验。本课题中用到大部分技术是我在过去的几年内掌握的,我感谢这里年内教授我欣赏我的吴定璋、陈木生、饶友兰、万立中、朱俊炎、刘晓强等老师,他们给了我很大的鼓励,还有足够的宽容。

在我初学J2ME的技术的时候,从J2ME开发网获得了大量的软件工具和开发资料,在它的论坛中得到了包括站长詹亮飞在内的很多朋友的指点。在我写论文的时候我再一次找到了他的J2ME开发网,还从他著的《J2ME开发精解》中引用过字句。感谢詹亮飞和他的J2ME开发网,感谢所有在网上无私提供J2ME心得的朋友。

最后我还要再一次感谢我的指导老师万立中,感谢他对我的信任和督促,他的认真和负责令人钦佩。

下载

J2ME_PopTang_By_Cpiz.pdf
PopTang0705202234.rar